Module: ESM과 CJS는 뭐가 다른걸까?
Module의 등장 배경
초기의 자바스크립트는 웹페이지에 간단한 동작을 추가하는 스크립트 언어였다.
그래서 <script>태그를 여러 개 써서, 각기 다른 자바스크립트 파일을 불러오는 식으로 코드를 썼다.
<script src="a.js"></script>
<script src="b.js"></script>
이 구조에는 문제들이 있었는데
-
전역 스코프 오염
모든 스크립트가 같은 전역(global) 공간을 공유했기 때문에,
a.js에서var count = 1을 정의하고,b.js에서 같은 이름을 쓰면 덮어쓰기 되었다
즉, 파일 간의 의존성과 캡슐화가 전혀 없었어.그래서 즉시실행함수(IIFE)를 써서 명시적으로 스코프를 분리했다고 한다.
-
로드 순서 의존성
어떤 코드가 먼저 로드되어야 하는지가 중요했다.
예를 들어a.js에서 정의한 함수를b.js가 사용하려면, 반드시a.js를 먼저 불러와야 했다.
그렇지 않으면undefined is not a function에러가 발생했다. -
코드 재사용 불가 / 유지보수 어려움
여러 페이지에서 동일한 기능을 쓰고 싶어도, 코드를 복사해서 붙여넣는 수밖에 없었다.
수정이 생기면 모든 파일을 찾아 바꿔야 했고, 버전 관리도 불가능했다. -
정적 분석과 최적화 불가능
어떤 파일이 어떤 파일을 쓰는지 브라우저가 알 수 없었다.
따라서 “의존성 그래프”를 파악하기 어려워서 빌드 시점 최적화(트리셰이킹, 코드 스플리팅) 같은 걸 할 수 없었다.
이런 한계를 해결하기 위해 커뮤니티가 자체적으로 만든 비표준 모듈 시스템이 등장했는데,
대표적으로 CommonJS(CJS)와 AMD. 그리고 나중에 언어 차원에서 표준화된 것이 ES Modules(ESM) 이다.
모듈 시스템은 UMD등 여러 가지가 있었지만, 현재 기준으로 ESM과 CJS 두 가지만 살아남았다고 봐도 무방하다.
CommonJS (CJS)
원래 자바스크립트는 브라우저 안에서만 실행되던 언어였다. Node.js가 등장하면서 파일 단위로 모듈을 관리할 필요했다.
서버 환경은 대규모 애플리케이션이 많았고, 브라우저보다 프로세스 수명이 길기 때문에,
한 번 충돌이 나면 오작동하거나 잘못된 상태를 장기간 유지할 위험이 있었다.
- 파일 단위로 코드를 분리하고
- 각 파일이 독립된 스코프를 가지며
- 필요한 모듈을 불러오고(export / import) 내보내는 기능이 필요했다.
즉, CJS는 브라우저가 아닌 서버환경에서 자바스크립트를 안전하게 “파일 단위 모듈로” 관리하기 위해,
초창기부터 채택한 서버용 모듈 시스템으로 require() / module.exports 문법으로 이루어짐.
// math.js (CJS)
function add(a, b) {
return a + b;
}
module.exports = { add };
// main.js (CJS)
const { add } = require('./math');
console.log(add(1, 2)); // 3
Node가 .js 파일을 실행하면, 내부적으로 다음처럼 즉시실행함수로 래핑한다.
즉시실행함수로 감싸서 실행하기 때문에, 작성한 파일이 자동으로 모듈 단위 스코프가 된다.
이 즉시실행함수의 결과물은 module.exports에 담기고,
require를 통해서 그 모듈의 module.exports 객체를 받아온다.
(function (exports, require, module, __filename, __dirname) {
// 작성한 코드...
})();
아래에서 CJS의 특징에 대해 더 얘기해보자
CJS : require는 [동기적]으로 로드한다.
require는 동기적으로 모듈을 로드한다. 동기적으로 로드한다는 것은 모듈을 불러오는 작업을 메인스레드에서 실행한다는 뜻이다. 그 동안에는 다른 자바스크립트는 실행되지 않는다.
서버는 로컬의 파일 시스템에서 모듈을 읽어오기 때문에, 디스크접근이 빨라서 동기적으로 불러오는 것도 빠르다.
그래서 Node.js는 CJS의 require()를 채택해도 성능 문제 없이 잘 돌아간다.
또, 서버는 시작 시 필요한 모든 모듈을 미리 불러온다. 서버 부팅 시점(초기화 단계)에 미리 호출해두고, 준비된 상태로 요청을 처리하기 때문에, 모듈 로딩 과정에서 성능 문제를 겪을 일이 적다.
예를 들어, Express.js 서버가 시작될 때를 생각해보자.
// app.js
const express = require('express');
const app = express();
// 미들웨어 및 라우트 설정
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
- app.js가 Node.js에 의해 실행된다.
- require('express')가 호출되어, 로컬 파일 시스템에서 Express 모듈을 동기적으로 로드한다.
- Express 모듈이 로드되고, 내부적으로 필요한 다른 모듈들도 동기적으로 로드된다.
- 모든 모듈이 로드되고, 서버가 초기화된다.
- 서버가 요청을 받을 준비가 완료된다.
이 과정에서 모든 모듈 로딩이 서버 시작 시점에 완료되기 때문에, 이후 요청 처리 시에는 이미 메모리에 로드된 모듈을 사용하게 된다. 따라서 동기적인 모듈 로딩이 서버 성능에 큰 영향을 미치지 않는다.
CJS require / module.exports는 [동적 호출] 이다.
아래와 같이, CJS의 require / module.exports 는 대상을 동적으로 변경 할 수 있다.
// 1) 경로가 동적
const name = process.env.FEATURE ?? userInput; // 런타임 값
const m = require('./features/' + name + '.js'); // 어떤 파일일지 미리 단정 불가
// 2) export 형태가 런타임에 바뀜
if (Math.random() > 0.5) {
module.exports = require('./implA');
} else {
module.exports = require('./implB');
}
-
동적인
require()호출 ‘시점’ 에 로드/평가한다
조건에 따라 모듈을 불러오거나 변수를 활용해 모듈을 불러올 수 있다. -
동적인
module.exports어떤 값을 내보낼지를 실행 중에 바꿀 수 있으며, 하나의 객체로 내보내기 때문에
exports.foo = ...; exports.bar = ...;처럼 여러 속성을 동적으로 추가할 수도 있다.
이 두 가지 “동적성” 때문에, CJS는 빌드 타임에 의존 그래프를 확정하기 어렵다.
뭐가 사용되는지 뭐가 사용되지 않는지 빌드타임에 판단이 어려워서 트리쉐이킹이 어렵다.
번들러가 “사용하지 않는 코드”를 제거하려면 “정확히 무엇이 import/require되는지” 알아야 하는데,
CJS의 동적성은 이 판단을 불안정하게 만든다.
ES Modules (ESM)
브라우저 JS 진영에선, 동기적인 CJS를 쓸 수 없었다. 모듈을 쓰고자 CJS를 사용 가능케하는 AMD(Asynchronous Module Definition), UMD등을 만들어 썼는데, 복잡하고 혼종이 난립했다. 드디어 ECMAScript 2015(ES6)에서 자바스크립트 언어 표준으로 도입되었다.
클라이언트에서는 왜 동기적 로드를 쓸 수 없을까?
동기적으로 모듈을 로드한다는 것은, 브라우저의 메인 스레드를 쓰겠다는 것과 같다.
script를 실행할 때, require()가 호출되면, 그 순간 네트워크를 통해 모듈을 가져오고 평가하는 동안
메인스레드는 멈춰있게 된다. 그 동안 유저들의 화면이 먹통이 될 것이다.
애초에, require는 Node 런타임의 기능이기 때문에, 클라이언트에서는 require를 모른다.
자바스크립트 엔진이나 브라우저 자체에서는 함수를 찾을 수 없어서 CJS를 쓸 수가 없다.
하지만 우리는
lodash같은 CJS로 구현된 것들을 클라이언트에서 불러서 쓰지 않는가?
이건 번들러가 미리 처리를 해줘서 브라우저에겐 require를 알 필요가 업게 만들어 주기 때문이다.
ESM : import/export는 [정적, 비동기]다.
import / export 문법을 사용하며, 브라우저와 Node.js 모두 지원하게 되었다.
import / export는 반드시 파일 최상단(top-level) 에 있어야 한다.
CJS의 require()는 “코드를 실행하는 순간” 모듈을 읽고 평가한다(동기 로드).
반면, ESM의 import는 “실행 전에” 모듈을 분석하고 로드한다. (정적 로드, 정적 평가)
아래 esm 파일 실행되는 순서를 보면,
// main.mjs
import { foo } from './foo.js';
console.log(foo);
1. 브라우저가 `main.js`를 JS파일을 "파싱"하면서 → `import ./foo.js`를 발견
2. `foo.js`도 fetch 하고 "DFS"로 import 문을 탐색
3. 이때 모든 모듈을 병렬로 fetch하고 "의존성 그래프"를 만든다.
4. 의존성 그래프대로 하위의 모듈들을 "Evaluate(평가)"한다.
5. 모든 모듈들이 Resolve된 뒤 `main.js`가 실행된다.
이걸 ESM 정적 분석 단계로 나타내면 아래와 같다.
- Parsing : import/export를 분석해 DFS로 의존성 그래프 생성 및 병렬로 fetch
- Linking : 모듈 간 변수 참조를 연결 (메모리 레벨에서 바인딩 = 참조)
- Evaluation : 의존성그래프를 기준으로 위상정렬로 코드 실행 (함수 실행, 변수 초기화)
- 그리고 실제 진입점을 실행시킨다.
즉, 브라우저나 Node는 코드가 실행되기 전에,
의존성의 관계를 판단하고(parsing),
import한 변수명과 실제 파일의 export 값을 바인딩 (Linking)하며,
의존되는 모듈들을 순서대로 실행 (Evaluation)하게 된다.
“정적 분석 가능하다”는 진짜 의미
CJS의 동적인 require/module.exports는 런타임이 되어야 의존 관계를 판단할 수 있어서
어떤 코드가 쓰이는지 안쓰이는지를 미리 판단하기 어렵다.
ESM처럼 정적 분석이 가능하면 미리 예측이 가능하다.
즉, 빌드 도구(webpack, Rollup, Vite 등)에서는 트리쉐이킹 같은 최적화를 할 수 있다
ESM : import / export는 정적 바인딩을 가진다
CJS는 require() 시점에 exports 객체를 복사한다.그래서 원본 모듈의 값이 바뀌어도 가져온 쪽은 그대로다.
ESM은 모듈 간 변수를 참조한다. 값을 복사해오는 게 아니라 원본 변수를 바라본다.
CJS의 require는 module.exports 객체를 복사해서 가지고 있는 것이고 ESM의 import는 실제 함수나 변수에 바인딩 해둔 것이다.
// counter.mjs
export let count = 0;
export function inc() { count++; }
// main.mjs
import { count, inc } from './counter.js';
inc();
console.log(count); // ✅ 1 (원본 참조)
가져온 식별자는 “읽기 전용”이라 재할당은 불가하지만, 원본에서 값이 바뀌면 자동으로 반영된다.
import/export가 정적(Static) 문법으로 처리되고, 런타임 링크 시점에 서로의 메모리 참조를 연결하기 때문에 가능하다.
반면 CJS에서는 원본 값이 바뀌어도, require한 측에서는 바뀐 원본 값을 추적하지 못한다.
ESM 와 CJS 호환
ESM 에서 어떻게 CJS 파일을 불러올 수 있을까 ?
ESM에서 CJS를 부르면 정상적으로 동작한다.
// math.cjs
module.exports = {
add(a, b) {
return a + b;
},
sub(a, b) {
return a - b;
},
};
// main.mjs
import math from './math.cjs';
console.log(math.add(1, 2)); // ✅ 3
ESM의 import는 바인딩을 위해 모듈에서 key-value 객체를 내려보내주길 기대한다.
ESM에서 CJS를 import하게 되면,
각 런타임들은 이 기대에 맞게 CJS를 래핑해주기 때문에 정상 동작한다.
CJS의 module.exports를 { default : module.exports } 형태로 변환해주는 덕분이다.
Webpack같은 번들러가 번들링을 할 때도,
정적 분석하는 타이밍에 module.exports를 { default : module.exports }로 치환을 해준다.
다만 ESM기반으로 설계된 Vite같은 번들러는 따로 CJS를 사용할 수 있도록 플러그인을 추가해야한다.
CJS에서 ESM은 어떻게 부를까 ?
대부분 정상적으로 동작하지 않는다.
: require() 가 기대하는 바를 들어주지 못한다.
안되는 이유 1 : Top-Level Await
ESM에서는 Top-Level Await(TLA)이 가능하다. 일반적으로 await는 async 함수 내부에서만 사용할 수 있지만, ESM 모듈의 최상위 스코프에서는 함수 밖에서도 await를 사용할 수 있게 되었다.
ESM은 TLA를 사용하면 모듈 평가(Evaluation) 과정이 비동기로 바뀐다.
import 구문 자체가 Promise를 resolve 해야만 완료된다.
반면, CJS(CommonJS)는 동기적인 모듈 시스템이다.require()를 호출하면 즉시 값이 반환되어야 하며,
내부적으로는 파일을 동기적으로 읽고 실행한 뒤 결과를 바로 반환한다.
동기적인 CJS의 require()가 ESM의 비동기 모듈을 기다릴 방법이 없기 때문이다.
안되는 이유 2 : 인터페이스 차이
require()는 가져오는 모듈에 module.exports 객체가 있기를 기대한다.
하지만 ESM에는 이런 객체가 없다.
그리고 ESM에서 CJS 호출할때 런타임들이 호환(interop)을 위해서 처리해주는 것과 달리,
CJS에서 ESM을 호출할 때는 따로 interop을 위한 처리를 안해준다
require(esm)은 undefined 이 될 수 밖에 없다.
그렇다면 절대 CJS에서는 ESM을 못 쓸까? : 그렇진 않다.
CJS에는 동적 import가 존재하는데, 즉, await import(.mjs)은 기본적으로 가능하다.
그래서 아래와 같이 즉시실행함수를 만들어서 ESM을 불러오는 방식은 가능하다.
(async () => {
try {
const esm = await import('./esm-module.mjs');
console.log(esm.hello); // "Hello from ESM"
console.log(esm.add(1, 2)); // 3
} catch (err) {
console.error('Failed to load ESM:', err);
}
})();
또, 클라이언트 프로젝트에는 항상 번들러가 존재한다. 번들러는 Node.js와 달리 빌드 타임에 CJS와 ESM을 같은 실행 모델로 강제 통합할 수 있다. 번들러는 코드를 정적 분석(빌드 타임 파싱)하면서 필요한 경우 CJS → ESM, 혹은 ESM → CJS 형태로 자동 변환해버린다.
따라서, CJS 코드 내부에 require('.mjs')처럼 ESM을 불러오는 구문이 있어도
번들러에 적절한 플러그인이 설정되어 있다면
해당 ESM을 미리 파싱하여 CJS처럼 작동하도록 코드 변환을 수행한다.
CJS와 ESM은 어떻게 만드는걸까?
자바스크립트에는 CommonJS와 ESM이라는 두 가지 모듈 시스템이 공존하고 있다. Node.js와 TypeScript는 둘 중 어떤 방식으로 파일을 해석할지를 확장자, package.json의 type 필드, 그리고 TypeScript의 경우 tsconfig 설정을 기반으로 결정한다.
가장 중요한 기준은 package.json 의 type 필드이다.
이 값이 commonjs(기본값)이면 모든 .js 파일은 CommonJS로 해석되고,
module이면 동일한 .js 파일이라도 ESM으로 처리된다.
확장자
반면 .cjs와 .mjs 는 언제나 명확하게 동작한다. .cjs 는 파일이 어떤 프로젝트 안에 있든 항상 CommonJS로 실행되고,
.mjs 역시 항상 ESM으로 실행된다. 이 두 확장자는 모듈 시스템을 강제로 고정하고 싶을 때 사용된다.
TypeScript도 4.7 버전부터 Node.js의 모듈 규칙을 그대로 따라가기 시작했다. moduleResolution을 설정을 맞추면,
.ts 파일도 package.json 의 type 설정에 따라 CJS 또는 ESM으로 해석된다.
추가로 TypeScript는 .cts 와 .mts 라는 전용 확장자를 제공하는데, .cts 는 항상 CommonJS로, .mts 는 항상 ESM으로 처리된다.
References